Skip to content

fix(swift-sdk): act on swept transactions in the SwiftData store - #4589

Open
romchornyi wants to merge 1 commit into
split/4406-3-producerfrom
split/4406-5-swift
Open

fix(swift-sdk): act on swept transactions in the SwiftData store#4589
romchornyi wants to merge 1 commit into
split/4406-3-producerfrom
split/4406-5-swift

Conversation

@romchornyi

Copy link
Copy Markdown
Contributor

Stacked on #4560. Review only this PR's own diff; its base is split/4406-3-producer.
Fourth of the five PRs #4406 was split into: seam → storage → producer → Swift → Kotlin.

Issue being fixed or feature implemented

Until this lands, the Swift host publishes the legacy struct_size, the negotiated sweeps slot reads None, CORE_SWEEP_REMOVAL is withheld, and Rust fail-closes: an iOS wallet freezes its sync watermark on the first sweep it meets rather than diverging. Funds-safe, but a user-visible stall — this is the PR that ends it.

What was done?

The store

SwiftData rows can be shared across wallets, so a sweep marks rather than deletes: isGloballySwept excludes the row and its outputs from every restore and enumeration path, and the physical delete is left to housekeeping once every wallet's scoped cleanup has landed. A tombstone must likewise outlive its loser — detach it and the consumed coin reads unspent again.

Held inputs become pending-input tombstones carrying the winner and, when it was mined, its height. A chained sweep repoints an earlier tombstone at the new winner rather than stacking a second hold. The release pass is outpoint-keyed, the drain gives tombstones precedence over ordinary observations, and isSpent stays monotonic against them: a hold the sweep proved consumed is never downgraded by a later record — not even the winner's own, which can arrive IS-locked, a context below in-block.

autosaveEnabled goes off on the round context. Sweeps travel in their own callback, so a round now spans two calls, and an autosave landing between them would make the watermark and the additive rows durable while the removal is still unstaged — with rollback() unable to take back a save that already happened. The handler attests ATOMIC_CHANGESETS and Rust relies on that to trust the split transport, so the guarantee has to be real.

Schema V4, and the freeze it required

The four models that gain a column (PersistentTransaction, PersistentTxo, PersistentPendingInput, PersistentWallet) were still referenced live by DashSchemaV1/V2/V3. Adding a property to a live model mutates those released versions' checksums in place, so a store written by a shipped binary matches no registered schema and fails to open with Cocoa 134504 instead of migrating — exactly what DashSchemaFrozenModels.swift was introduced to prevent, and its instruction is to freeze the model you change.

Freezing those four alone is not possible: a frozen model declares its relationships against frozen counterparts (an inverse: key path is typed on the destination model), and following relationships in both directions closes over 24 of the 35 models — a schema holds one type per entity name, so the component travels together. All 24 are frozen at their V3 shape, shared by V1/V2/V3, none of which changed any of them. The eleven models outside the component remain live-referenced and still carry the latent defect, unchanged by this PR.

DashSchemaV4 then registers the live models with a lightweight V3→V4 stage: every new column is additive with a default or optional, so existing rows migrate as not-swept, unsuperseded, ordinary unstamped claims, and a wallet with no chainlock boundary yet.

Merge with #4356

#4356 landed first and rewrote the same three regions. Its reconcileSpendObservation stays the single spend verdict, extended with one sweep term — a stamped hold outranks any observation — and its oldest-first pending-row reconciliation stays, under a tombstone-precedence branch.

One correction the merge forced: the "never displace confirmed evidence" rule refused the link when isSpent was true with no spender linked, which is precisely the sweep-hold shape, so the winner's own record could never supply the attribution the hold lacked. With no link there is nothing to displace, so it is adopted.

One gap neither PR covered is closed here: buildUnresolvedAssetLockTxRecordBuffer skips globally-swept rows, so the double-spend screen can never be handed a swept loser as the settled spender of a lock's input.

Also carried

The ChangesetRoundIndex per-round fetch cache — the reviewed-but-untested fix for the quadratic SwiftData fetch that put ~99% of CPU on the serial queue. Sweep paths deliberately opt out of it, since they key on mutable columns the index cannot answer stale. Named explicitly because it is the one piece here without dedicated tests.

How Has This Been Tested?

xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17'437 tests, and swift build clean under the package's -warnings-as-errors.

  • SweptTransactionPersistTests (38): shared losers, detached tombstones with a missing winner row, chained tombstones, cross-round reinstatement, released-pending deadlock, co-swept twins, the throwing-lookup round failure, and the winner's late record against a stamped hold.
  • DashModelMigrationTests: gains testV3StoreMigratesToV4AndBackfillsTheSweepColumns, and its V1/V2 cases now write and read through the frozen types.
  • InvitationPersistenceTests tracks the new capability mask.

The only failures on this machine are two KeychainSignerAdditionalSigningKeysTests cases, which fail identically on an unmodified checkout — the bare xcodebuild run has no writable keychain, which run_tests.sh provides in CI.

Breaking Changes

None at the API surface. Schema V4 is a lightweight migration; the freeze exists specifically so V1/V2/V3 stores keep opening.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 5f4d0821-e7d2-472e-9559-5fe9ef4e3ceb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — 55 ahead in queue (commit 73a9ea7)
Queue position: 56/58 · 2 reviews active
ETA: start ~2026-09-04 12:03 UTC · complete ~2026-09-04 12:58 UTC (median 54m across 30 recent reviews; 2 slots)
Queued 46m ago · Last checked: 2026-09-03 11:20 UTC

The SwiftData mirror of the storage contract, complicated by two things
SQLite does not have: rows shared across wallets, and a round that now
spans two callbacks.

Shared rows are why a sweep marks rather than deletes. A transaction row
can belong to several wallets, so the first wallet's callback cannot
remove it — it sets `isGloballySwept`, which excludes the row and its
outputs from every restore and enumeration path, and the physical delete
is left to housekeeping once every wallet's scoped cleanup has landed. A
tombstone must likewise outlive its loser: detach it and the consumed
coin reads unspent again.

Held inputs become pending-input tombstones carrying the winner and,
when it was mined, its height; a chained sweep repoints an earlier
tombstone at the new winner rather than stacking a second hold. The
release pass is outpoint-keyed, the drain gives tombstones precedence
over ordinary observations, and `isSpent` stays monotonic against them:
a hold the sweep proved consumed is never downgraded by a later record —
not even the winner's own, which can arrive IS-locked, a context below
in-block.

`autosaveEnabled` goes off on the round context. Sweeps travel in their
own callback, so the round spans two calls, and an autosave landing
between them would make the watermark and the additive rows durable
while the removal is still unstaged — with `rollback()` unable to take
back a save that already happened. The handler attests
`ATOMIC_CHANGESETS`, and Rust now relies on that to trust the split
transport, so the guarantee has to be real. The handler declares
`CORE_SWEEP_REMOVAL` and `DASHPAY_PAYMENTS`; before this commit it
published the legacy `struct_size`, the negotiated slot read `None`, and
Rust fail-closed.

## Schema V4, and the freeze it required

The four models that gain a column — `PersistentTransaction`,
`PersistentTxo`, `PersistentPendingInput`, `PersistentWallet` — were
still referenced live by `DashSchemaV1/V2/V3`. Adding a property to a
live model mutates those released versions' checksums in place, so a
store written by a shipped binary would match no registered schema and
fail to open with Cocoa 134504 instead of migrating. That is exactly the
defect `DashSchemaFrozenModels.swift` was introduced to prevent, and its
instruction is to freeze the model you change.

Freezing those four alone is not possible: a frozen model declares its
relationships against frozen counterparts (an `inverse:` key path is
typed on the destination model), and following relationships in both
directions closes over 24 of the 35 models — one type per entity name is
all a schema can hold, so the component travels together. All 24 are
frozen here at their V3 shape, shared by V1, V2 and V3, none of which
changed any of them. The eleven models outside the component are still
live-referenced and still carry the latent defect, unchanged by this.

`DashSchemaV4` then registers the live models with a lightweight V3→V4
stage: every new column is additive with a default or optional, so
existing rows migrate as not-swept, unsuperseded, ordinary unstamped
claims, and a wallet with no chainlock boundary yet.

## Merge with #4356

#4356 landed first and rewrote the same three regions. Its
`reconcileSpendObservation` stays the single spend verdict, extended
with one sweep term — a stamped hold outranks any observation — and its
oldest-first pending-row reconciliation stays, under a
tombstone-precedence branch. One correction the merge forced: the "never
displace confirmed evidence" rule refused the link when `isSpent` was
true with NO spender linked, which is precisely the sweep-hold shape, so
the winner's own record could never supply the attribution the hold
lacked. With no link there is nothing to displace, so it is adopted.

One gap neither PR covered is closed here:
`buildUnresolvedAssetLockTxRecordBuffer` now skips globally-swept rows,
so the double-spend screen can never be handed a swept loser as the
settled spender of a lock's input.

Also carries the `ChangesetRoundIndex` per-round fetch cache — the
reviewed-but-untested fix for the quadratic SwiftData fetch that put
~99% of CPU on the serial queue. Sweep paths deliberately opt out of it,
since they key on mutable columns the index cannot answer stale.

Tests: `SweptTransactionPersistTests` (38) — shared losers, detached
tombstones with a missing winner row, chained tombstones, cross-round
reinstatement, released-pending deadlock, co-swept twins, the
throwing-lookup round failure, and the winner's late record against a
stamped hold. `DashModelMigrationTests` gains the V3→V4 stage and reads
V1/V2 rows through the frozen types. Full suite: 437 tests, the only
failures being two `KeychainSignerAdditionalSigningKeysTests` cases that
fail identically on an unmodified checkout (the test host cannot write
to the keychain).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants